Skip to content

feat: add v2 codemod draft#1950

Draft
KKonstantinov wants to merge 38 commits intomainfrom
feature/v2-codemode-draft
Draft

feat: add v2 codemod draft#1950
KKonstantinov wants to merge 38 commits intomainfrom
feature/v2-codemode-draft

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

@KKonstantinov KKonstantinov commented Apr 23, 2026

feat: add @modelcontextprotocol/codemod package for automated v1 → v2 migration

Adds a new @modelcontextprotocol/codemod package that automatically migrates MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to the v2 multi-package architecture (@modelcontextprotocol/client, /server, /core, /node, /express). Powered by ts-morph for AST-level precision and shipped as both a CLI (mcp-codemod) and a programmatic API. Also automatically updates package.json — removes the v1 SDK dependency and adds the correct v2 packages based on what the code actually imports.

Motivation and Context

The v2 SDK introduces a multi-package structure, renamed APIs, restructured context objects, and removed modules (WebSocket transport, server auth, Zod helpers). Manually migrating a codebase is tedious, error-prone, and blocks adoption. This codemod handles the mechanical 80-90% of migration — rewriting imports, renaming symbols, updating method signatures, and mapping context properties — while emitting actionable diagnostics for the remaining cases that require human judgment.

Architecture

Package Structure

packages/codemod/
├── src/
│   ├── cli.ts                  # Commander CLI entry point
│   ├── runner.ts               # Core orchestrator
│   ├── types.ts                # Transform / Migration / Diagnostic types
│   ├── index.ts                # Public API exports
│   ├── migrations/
│   │   ├── index.ts            # Migration registry
│   │   └── v1-to-v2/
│   │       ├── index.ts        # Migration definition
│   │       ├── mappings/       # Declarative lookup tables
│   │       │   ├── importMap.ts
│   │       │   ├── symbolMap.ts
│   │       │   ├── schemaToMethodMap.ts
│   │       │   └── contextPropertyMap.ts
│   │       └── transforms/     # Ordered AST transforms
│   │           ├── index.ts
│   │           ├── importPaths.ts
│   │           ├── symbolRenames.ts
│   │           ├── removedApis.ts
│   │           ├── mcpServerApi.ts
│   │           ├── handlerRegistration.ts
│   │           ├── schemaParamRemoval.ts
│   │           ├── expressMiddleware.ts
│   │           ├── contextTypes.ts
│   │           └── mockPaths.ts
│   ├── generated/
│   │   └── versions.ts            # Build-time v2 package versions
│   └── utils/
│       ├── astUtils.ts            # AST rename helpers
│       ├── diagnostics.ts         # Diagnostic factories
│       ├── importUtils.ts         # Import manipulation
│       ├── packageJsonUpdater.ts  # Automatic package.json updates
│       └── projectAnalyzer.ts     # Detects client/server/both
├── scripts/
│   └── generate-versions.ts       # Reads sibling package versions at build time
└── test/                          # 14 test suites, 201 test cases

High-Level Flow

flowchart TD
    A["mcp-codemod v1-to-v2 ./src"] --> B[CLI parses args]
    B --> C[Runner loads migration]
    C --> D[Analyze project type<br/>from package.json]
    D --> E[Create ts-morph Project<br/>glob .ts/.tsx/.mts files]
    E --> F[Filter out node_modules,<br/>dist, .d.ts, .d.mts]
    F --> G{For each<br/>source file}
    G --> H[Apply transforms<br/>in order]
    H --> I[Collect diagnostics,<br/>change counts,<br/>& used v2 packages]
    I --> G
    G -->|done| P[Update package.json:<br/>remove v1 SDK,<br/>add detected v2 packages]
    P --> J{Dry run?}
    J -->|yes| K[Report changes<br/>without saving]
    J -->|no| L[Save modified files<br/>& package.json to disk]
    K --> M[Print summary:<br/>files changed,<br/>package.json changes,<br/>diagnostics]
    L --> M
Loading

Transform Pipeline

Transforms run in a strict order per file. Each transform receives the SourceFile AST, mutates it in place, and returns a change count plus diagnostics. One failing transform does not block the others.

flowchart LR
    subgraph "Phase 1: Foundation"
        T1["1. importPaths<br/>Rewrite import specifiers<br/>from v1 → v2 packages"]
    end

    subgraph "Phase 2: Symbols"
        T2["2. symbolRenames<br/>McpError → ProtocolError<br/>ErrorCode split, etc."]
        T3["3. removedApis<br/>Drop Zod helpers,<br/>IsomorphicHeaders"]
    end

    subgraph "Phase 3: API Surface"
        T4["4. mcpServerApi<br/>.tool() → .registerTool()<br/>restructure args"]
        T5["5. handlerRegistration<br/>Schema → method string"]
        T6["6. schemaParamRemoval<br/>Remove schema args"]
        T7["7. expressMiddleware<br/>hostHeaderValidation<br/>signature update"]
    end

    subgraph "Phase 4: Context & Tests"
        T8["8. contextTypes<br/>extra → ctx,<br/>property path remapping"]
        T9["9. mockPaths<br/>vi.mock / jest.mock<br/>specifier updates"]
    end

    T1 --> T2 --> T3 --> T4 --> T5 & T6 & T7 --> T8 --> T9
Loading

Import Resolution Strategy

Some v1 paths (e.g., @modelcontextprotocol/sdk/types.js) are shared code that could belong to either the client or server package. The codemod resolves these contextually:

flowchart TD
    A["v1 import path"] --> B{Path in<br/>IMPORT_MAP?}
    B -->|no| Z[Leave unchanged]
    B -->|yes| C{Status?}
    C -->|removed| D["Remove import +<br/>emit warning diagnostic"]
    C -->|moved| E{Target is<br/>RESOLVE_BY_CONTEXT?}
    C -->|renamed| F["Rewrite path +<br/>rename symbols"]
    E -->|no| G["Rewrite to<br/>fixed target package"]
    E -->|yes| H{Project type?}
    H -->|client only| I["→ @modelcontextprotocol/client"]
    H -->|server only| J["→ @modelcontextprotocol/server"]
    H -->|both or unknown| K["→ @modelcontextprotocol/server<br/>(safe default)"]
Loading

Context Property Remapping

The v2 SDK restructures the handler context from a flat object into nested groups. The contextTypes transform handles this remapping:

flowchart LR
    subgraph "v1 — flat RequestHandlerExtra"
        E1["extra.signal"]
        E2["extra.requestId"]
        E3["extra.authInfo"]
        E4["extra.sendRequest(...)"]
        E5["extra.sendNotification(...)"]
        E6["extra.taskStore"]
    end

    subgraph "v2 — nested BaseContext"
        C1["ctx.mcpReq.signal"]
        C2["ctx.mcpReq.id"]
        C3["ctx.http?.authInfo"]
        C4["ctx.mcpReq.send(...)"]
        C5["ctx.mcpReq.notify(...)"]
        C6["ctx.task?.store"]
    end

    E1 --> C1
    E2 --> C2
    E3 --> C3
    E4 --> C4
    E5 --> C5
    E6 --> C6
Loading

What Each Transform Does

# Transform ID Description
1 imports Rewrites all @modelcontextprotocol/sdk/... import paths to their v2 package destinations. Merges duplicate imports, separates type-only imports, resolves ambiguous shared paths by project type. Splits imports when symbols from a single v1 module map to different v2 packages (e.g., StreamableHTTPServerTransport/node, EventStore/server). Tracks which v2 packages are used for automatic package.json updates.
2 symbols Renames 9 symbols (e.g., McpErrorProtocolError). Splits ErrorCode into ProtocolErrorCode + SdkErrorCode based on member usage. Converts RequestHandlerExtraServerContext/ClientContext. Replaces SchemaInput<T> with StandardSchemaWithJSON.InferInput<T>.
3 removed-apis Removes references to dropped Zod helpers (schemaToJson, parseSchemaAsync, etc.), renames IsomorphicHeadersHeaders, converts StreamableHTTPErrorSdkError with constructor mapping warnings.
4 mcpserver-api Rewrites McpServer method calls: .tool().registerTool(), .prompt().registerPrompt(), .resource().registerResource(). Restructures 2-4 positional args into (name, config, callback) form. Wraps raw object schemas with z.object().
5 handlers Converts server.setRequestHandler(CallToolRequestSchema, ...) to server.setRequestHandler('tools/call', ...) using the schema-to-method mapping table. Covers 15 request schemas and 8 notification schemas.
6 schema-params Removes the schema parameter from .request(), .callTool(), and .send() calls where the second argument is a schema reference (ends with Schema).
7 express-middleware Updates hostHeaderValidation({ allowedHosts: [...] })hostHeaderValidation([...]).
8 context Renames the extra callback parameter to ctx. Rewrites 13 property access paths from the flat v1 context to the nested v2 context structure. Warns on destructuring patterns that need manual review.
9 mock-paths Rewrites vi.mock() / jest.mock() specifiers from v1 to v2 paths. Updates import() expressions in mock factories. Renames symbol references inside mock factory return objects.

CLI Usage

# Run all transforms
mcp-codemod v1-to-v2 ./src

# Dry run — preview without writing
mcp-codemod v1-to-v2 ./src --dry-run --verbose

# Run specific transforms only
mcp-codemod v1-to-v2 ./src --transforms imports,symbols,context

# List available transforms
mcp-codemod v1-to-v2 --list

# Ignore additional patterns
mcp-codemod v1-to-v2 ./src --ignore "legacy/**" "generated/**"

Programmatic API

import { getMigration, run } from '@modelcontextprotocol/codemod';

const migration = getMigration('v1-to-v2')!;
const result = run(migration, {
  targetDir: './src',
  dryRun: false,
  verbose: true,
  transforms: ['imports', 'symbols'],
  ignore: ['test/**']
});

console.log(`${result.filesChanged} files changed, ${result.totalChanges} total changes`);
for (const d of result.diagnostics) {
  console.log(`[${d.level}] ${d.file}:${d.line}${d.message}`);
}

How Has This Been Tested?

  • 201 test cases across 14 test suites covering every transform, the CLI, the runner, the project analyzer, and the package.json updater.
  • Each transform has its own dedicated test suite with isolated ts-morph in-memory filesystem tests.
  • Integration tests run the full pipeline against realistic v1 source files and verify complete output including package.json updates.
  • Package.json updater has dedicated unit tests covering: deps/devDeps/both sections, partial migration, dry-run, malformed JSON, private package filtering, indentation and trailing newline preservation.
  • Integration tests verify end-to-end package detection for client-only, server-only, client+server, express middleware, and split-import (symbolTargetOverrides) scenarios.
  • Edge cases tested: duplicate imports, type-only imports, removed APIs, ambiguous shared paths, ErrorCode member splitting, destructuring patterns, mock factories, .d.ts exclusion, unknown transform ID validation.

Breaking Changes

None — this is a new package with no existing users.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Design Decisions

  1. ts-morph over jscodeshift: ts-morph provides full TypeScript type information and a more ergonomic API for the precise symbol-level transforms this codemod requires (e.g., distinguishing ErrorCode.RequestTimeout from ErrorCode.InvalidRequest for the ProtocolErrorCode/SdkErrorCode split).

  2. Ordered transforms with per-file isolation: Transforms run in a declared order (imports first, mocks last). If a transform throws for a given file, the remaining transforms are skipped for that file (since the AST may be in a partially-mutated state), but processing continues for other files. An error diagnostic is emitted for the affected file.

  3. Declarative mapping tables: All rename/remap rules live in dedicated mapping files (importMap.ts, symbolMap.ts, etc.) rather than being scattered across transform logic. This makes the migration rules auditable and easy to extend.

  4. Context-aware import resolution: Shared v1 paths like @modelcontextprotocol/sdk/types.js are resolved to client or server packages based on package.json dependency analysis, not just hardcoded defaults.

  5. Diagnostic-first approach for removals: When the codemod encounters removed APIs (WebSocket transport, server auth, Zod helpers), it doesn't silently drop them — it emits clear warning diagnostics with migration guidance so users know what needs manual attention.

  6. Automatic package.json updates: As transforms rewrite imports, they track which v2 packages are targeted. After all source transforms complete, the runner removes @modelcontextprotocol/sdk from package.json and adds exactly the v2 packages the code actually uses. Version specifiers are injected at build time from sibling package versions via scripts/generate-versions.ts. Private packages (@modelcontextprotocol/core) are filtered out. The updater preserves the original indentation and trailing newline, and respects dry-run mode.

  7. Import splitting with symbolTargetOverrides: When symbols from a single v1 module map to different v2 packages (e.g., StreamableHTTPServerTransport@modelcontextprotocol/node but EventStore@modelcontextprotocol/server), the import map supports per-symbol target overrides. The transform splits the import into separate declarations for each target package.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: 3d43f78

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1950

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@1950

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1950

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1950

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@1950

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1950

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1950

commit: 3d43f78

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/cli.ts Outdated
Comment thread packages/codemod/src/cli.ts
Comment thread packages/codemod/src/utils/projectAnalyzer.ts
@KKonstantinov KKonstantinov changed the title feat: add v2 codemode draft feat: add v2 codemod draft Apr 23, 2026
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

Todo:

  • Test on sample size MCP SDK (v1) dependents
  • Test on everything-server
  • Test on v1 MCP SDK examples

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/runner.ts Outdated
Comment thread packages/codemod/src/runner.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/utils/astUtils.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts
Comment thread packages/codemod/src/cli.ts Outdated
Comment thread packages/codemod/src/index.ts
Comment thread packages/codemod/src/runner.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/test/integration.test.ts Outdated
Comment thread packages/codemod/src/utils/importUtils.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/client/src/validators/cfWorker.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts
Comment thread packages/codemod/src/runner.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +209 to +216
const parent = ref.getParent();
if (!parent) return false;
if (Node.isTypeQuery(parent)) return true;
// typeof inside a type argument like z.infer<typeof X>
if (parent.getKind() === SyntaxKind.TypeOfExpression) return true;
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The SyntaxKind.TypeOfExpression check on line 213 doesn't match what its comment describes — type-level typeof X (as in z.infer<typeof X> or type T = typeof X) is SyntaxKind.TypeQuery, which line 211 already covers; TypeOfExpression is the value-level runtime unary operator (const k = typeof x, evaluates to a string). So line 213 is dead for its stated purpose, and when it does fire (e.g. const kind = typeof ToolSchema) it emits a misleading "Replace z.infer<typeof ToolSchema> …" diagnostic for code that has no z.infer. Just delete lines 212-213 — Node.isTypeQuery is sufficient for all type-position typeof.

Extended reasoning...

What the issue is

isTypeofInTypePosition (specSchemaAccess.ts:208-215) checks two parent kinds:

if (Node.isTypeQuery(parent)) return true;
// typeof inside a type argument like z.infer<typeof X>
if (parent.getKind() === SyntaxKind.TypeOfExpression) return true;

In the TypeScript AST these are two distinct node kinds:

  • TypeQuery (SyntaxKind.TypeQuery) is type-level typeof X — used in type T = typeof X, z.infer<typeof X>, let a: typeof X. This is what line 211 matches, and it already covers every z.infer<typeof X> case.
  • TypeOfExpression (SyntaxKind.TypeOfExpression) is the value-level runtime unary operator — const k = typeof x, if (typeof x === 'object') — which evaluates to a string at runtime. This is what line 213 matches.

So the comment on line 212 ("typeof inside a type argument like z.infer") is factually incorrect about what TypeOfExpression matches — that case is TypeQuery, already handled one line up. Two verifiers confirmed this empirically against ts-morph 28.0.0: for z.infer<typeof Foo> and type T = typeof Foo the parent of Foo is TypeQuery (isTypeQuery → true, TypeOfExpression → false); for const kind = typeof Foo the parent is TypeOfExpression.

What it actually matches

Line 213 only fires for runtime typeof <schema>, e.g.:

import { ToolSchema } from '@modelcontextprotocol/server';
const kind = typeof ToolSchema;   // evaluates to "object" at runtime

When it fires, handleReference emits the diagnostic at line 73-78: "Replace z.infer<typeof ToolSchema> with the Tool type (already exported from the same v2 package)." — which is misleading: there's no z.infer, no type inference, and the user's intent (runtime typeof, always evaluates to "object") has nothing to do with type-position usage. The function name isTypeofInTypePosition also lies for this branch — TypeOfExpression is by definition not in type position.

Why the existing test doesn't exercise line 213

The test 'emits diagnostic for typeof in type position' (specSchemaAccess.test.ts:104-113) uses:

type Result = typeof CallToolResultSchema;

That's a TypeQuery — matched by line 211, not line 213. So the test passes regardless of whether line 213 exists, and provides no coverage for the TypeOfExpression branch.

Step-by-step proof

Input:

import { ToolSchema } from '@modelcontextprotocol/server';
const kind = typeof ToolSchema;
  1. collectSpecSchemaImports{ ToolSchema → ToolSchema }; typeName = 'Tool'.
  2. findNonImportReferences returns the ToolSchema identifier inside typeof ToolSchema. parent is a TypeOfExpression node (the runtime unary operator).
  3. handleReferenceisTypeofInTypePosition(ref): line 211 Node.isTypeQuery(parent)false (it's TypeOfExpression, not TypeQuery); line 213 parent.getKind() === SyntaxKind.TypeOfExpressiontrue. Returns true.
  4. handleReference line 72-79 pushes warning(..., "Replace z.inferwith theTool type ...") and returns false.
  5. The body reference is left unchanged, so removeUnusedImport at line 30 sees referenceCount === 1 and the ToolSchema import survives → eventually TS2305 (since v2 doesn't export individual *Schema names), but with a diagnostic that points the user at z.infer instead of the actual problem.

Impact

Diagnostic-quality / dead-code only — nit severity. Runtime typeof <ZodSchemaConstant> is essentially never written in real code (it always evaluates to "object", so it's a useless expression). The source is left unchanged (return false → safe, no corruption), and the only harm when triggered is a confusing diagnostic message. But the comment on line 212 is demonstrably wrong about TypeScript's AST, the function name lies for this branch, and the check is at-best-redundant for its stated purpose. This is the same incorrect-comment / dead-code cleanup bar already applied throughout this PR's review (resolved comments 3133089882, 3143732196).

Fix

Delete lines 212-213 — Node.isTypeQuery(parent) on line 211 already covers z.infer<typeof X>, type T = typeof X, and every other type-position typeof:

function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean {
    const parent = ref.getParent();
    if (!parent) return false;
    return Node.isTypeQuery(parent);
}

(If you actually want to handle runtime typeof XSchema — which seems unnecessary given how contrived it is — it should fall through to the value-position branch at line 168 and become typeof specTypeSchemas.Tool, not get the z.infer diagnostic.)

Comment on lines +159 to +170
if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === ref) {
return false;
}

if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref) {
return false;
}

// Value position: replace identifier with specTypeSchemas.X
const line = ref.getStartLineNumber();
ref.replaceWithText(`specTypeSchemas.${typeName}`);
ensureImport(sourceFile, 'specTypeSchemas');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 One more parent-kind gap in the same family as the just-added guards at lines 159/163: the PropertyAccessExpression check at line 122 only matches when ref is the object (parent.getExpression() === ref), so when a spec-schema name appears as the name node — obj.ToolSchema — it falls through to line 169 and emits obj.specTypeSchemas.Tool (valid syntax, so no ts-morph throw — silent corruption with a misleading 'Replaced ToolSchema…' diagnostic). renameAllReferences already has exactly this guard at astUtils.ts:17 (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node); add the same here returning false, since a property name on an arbitrary object is not a reference to the imported schema.

Extended reasoning...

What the bug is

handleReference() now guards ExportSpecifier (line 134), ShorthandPropertyAssignment (line 145), PropertyAssignment name-node (line 159), and BindingElement property-name (line 163) — the latter two added in the most-recent commit per comment 3193275781. But the PropertyAccessExpression check at line 122 only covers the case where ref is the object of the access (parent.getExpression() === ref, i.e. ToolSchema.something). There is no corresponding guard for when ref is the name node (parent.getNameNode() === ref, i.e. obj.ToolSchema). That case falls through every pattern check and lands on the value-position rewrite at line 169.

Code path that triggers it

findNonImportReferences() (lines 51-60) collects every identifier matching localName whose parent is not an ImportSpecifier. For obj.ToolSchema, the ToolSchema identifier's parent is a PropertyAccessExpression — not filtered — so it's pushed into refs and reaches handleReference().

Why existing code doesn't prevent it

Walking the guard chain for a ref whose parent is a PropertyAccessExpression with parent.getNameNode() === ref:

  • isTypeofInTypePosition — parent is not TypeQuery/TypeOfExpression → false.
  • isSafeParseSuccessPattern / isSafeParsePattern / isParsePattern — all check parent.getName() === 'safeParse'/'parse' (which is 'ToolSchema' here, not 'safeParse') and parent.getExpression() === ref (expression is obj, not ref) → false.
  • Line 122 — Node.isPropertyAccessExpression(parent) ✓ but parent.getExpression() === ref is false (expression is the obj identifier) → guard skipped.
  • Lines 134/145/159/163 — parent is not ExportSpecifier/ShorthandPropertyAssignment/PropertyAssignment/BindingElement → all skipped.
  • Line 169 — ref.replaceWithText('specTypeSchemas.Tool').

This is internally inconsistent: renameAllReferences (astUtils.ts:17) has exactly this guard — Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node → return — and the test 'does not rename property access names that match renamed symbols' (symbolRenames.test.ts) locks it in for that helper. specSchemaAccess is the one transform that re-implements its own parent-kind guard list and missed this case.

Step-by-step proof

Input:

import { ToolSchema } from '@modelcontextprotocol/server';
const x = registry.ToolSchema;
  1. collectSpecSchemaImports finds ToolSchema (in SPEC_SCHEMA_NAMES) → schemaImports = { ToolSchema → ToolSchema }, typeName = 'Tool'.
  2. findNonImportReferences(sf, 'ToolSchema'): visits the ToolSchema identifier inside registry.ToolSchema. Parent is PropertyAccessExpression, not ImportSpecifier → pushed.
  3. handleReference: every pattern check above is false; falls through to line 169.
  4. ref.replaceWithText('specTypeSchemas.Tool') → source becomes const x = registry.specTypeSchemas.Tool;. This is valid TypeScript syntax (chained property access), so ts-morph does not throw — unlike the PropertyAssignment/BindingElement cases that motivated comment 3193275781, there is no runner rollback here.
  5. ensureImport(sf, 'specTypeSchemas') adds specTypeSchemas to the import.
  6. A misleading 'Replaced ToolSchema with specTypeSchemas.Tool…' warning is pushed — the user is told a schema reference was migrated when in fact an unrelated property access on an arbitrary object was corrupted.
  7. removeUnusedImport(sf, 'ToolSchema', true) then removes the original ToolSchema import specifier (no body references remain).

Output:

import { specTypeSchemas } from '@modelcontextprotocol/server';
const x = registry.specTypeSchemas.Tool;

This may still typecheck (if registry is loosely typed) — silent semantic corruption, the worst codemod failure mode.

Impact

Nit severity. The trigger requires importing a spec schema from an MCP package and having an unrelated obj.<SchemaName> property access in the same file — contrived, in the same rarity class as the PropertyAssignment-name/BindingElement-propertyName cases just guarded at lines 159/163. But unlike those (which threw and triggered rollback), this one produces syntactically-valid output, so it's a silent corruption rather than a loud failure. It's a one-line internal-consistency fix in code that has been iteratively hardened across this PR for exactly this parent-kind-guard class (resolved comments 3189940697, 3193275781).

Fix

Add the missing guard alongside lines 159/163, mirroring astUtils.ts:17:

if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref) {
    return false;
}

(Returning false rather than emitting a diagnostic, since a property name on an arbitrary object is not a reference to the imported schema at all — same rationale as the PropertyAssignment-name guard at line 159.) Or, more durably, factor the key-position skip list out of renameAllReferences into a shared isKeyPositionIdentifier(node) helper and reuse it both here and in findNonImportReferences.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant